package config

import (
	"io/fs"
	"os"
	"path/filepath"
	"runtime"
	"strings"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"github.com/vektah/gqlparser/v2"
	"github.com/vektah/gqlparser/v2/ast"

	"github.com/99designs/gqlgen/internal/code"
)

func TestLoadConfig(t *testing.T) {
	t.Run("config does not exist", func(t *testing.T) {
		_, err := LoadConfig("doesnotexist.yml")
		require.Error(t, err)
	})
}

func TestReadConfig(t *testing.T) {
	t.Run("empty config", func(t *testing.T) {
		_, err := ReadConfig(strings.NewReader(""))
		require.EqualError(t, err, "unable to parse config: EOF")
	})

	t.Run("malformed config", func(t *testing.T) {
		cfgFile, err := os.Open("testdata/cfg/malformedconfig.yml")
		require.NoError(t, err)
		t.Cleanup(func() { _ = cfgFile.Close() })
		_, err = ReadConfig(cfgFile)

		actualErr := strings.ReplaceAll(err.Error(), "\r\n", "\n")
		require.Equal(
			t,
			"unable to parse config: [1:1] string was used where mapping is expected\n>  1 | asdf\n       ^\n",
			actualErr,
		)
	})

	t.Run("unknown keys", func(t *testing.T) {
		cfgFile, err := os.Open("testdata/cfg/unknownkeys.yml")
		require.NoError(t, err)
		t.Cleanup(func() { _ = cfgFile.Close() })
		_, err = ReadConfig(cfgFile)

		actualErr := strings.ReplaceAll(err.Error(), "\r\n", "\n")
		require.Equal(
			t,
			"unable to parse config: [2:1] unknown field \"unknown\"\n   1 | schema: outer\n>  2 | unknown: foo\n       ^\n",
			actualErr,
		)
	})

	t.Run("globbed filenames", func(t *testing.T) {
		cfgFile, err := os.Open("testdata/cfg/glob.yml")
		require.NoError(t, err)
		t.Cleanup(func() { _ = cfgFile.Close() })
		c, err := ReadConfig(cfgFile)
		require.NoError(t, err)

		if runtime.GOOS == "windows" {
			require.Equal(t, `testdata\cfg\glob\bar\bar with spaces.graphql`, c.SchemaFilename[0])
			require.Equal(t, `testdata\cfg\glob\foo\foo.graphql`, c.SchemaFilename[1])
		} else {
			require.Equal(t, "testdata/cfg/glob/bar/bar with spaces.graphql", c.SchemaFilename[0])
			require.Equal(t, "testdata/cfg/glob/foo/foo.graphql", c.SchemaFilename[1])
		}
	})

	t.Run("unwalkable path", func(t *testing.T) {
		cfgFile, err := os.Open("testdata/cfg/unwalkable.yml")
		require.NoError(t, err)
		t.Cleanup(func() { _ = cfgFile.Close() })
		_, err = ReadConfig(cfgFile)

		if runtime.GOOS == "windows" {
			require.ErrorContains(t, err, "failed to walk schema at root not_walkable/: ")
			// Go 1.24 and below report "CreateFile" but 1.25 and above report "GetFileAttributesEx"
			// in error
			// See https://go.dev/doc/go1.25#ospkgos
			require.ErrorContains(
				t,
				err,
				" not_walkable/: The system cannot find the file specified.",
			)
		} else {
			require.EqualError(t, err, "failed to walk schema at root not_walkable/: lstat not_walkable/: no such file or directory")
		}
	})
}

func TestLoadConfigFromDefaultLocation(t *testing.T) {
	testDir, err := os.Getwd()
	require.NoError(t, err)
	var cfg *Config

	t.Run("will find closest match", func(t *testing.T) {
		t.Chdir(filepath.Join(testDir, "testdata", "cfg", "subdir"))

		cfg, err = LoadConfigFromDefaultLocations()
		require.NoError(t, err)
		require.Equal(t, StringList{"inner"}, cfg.SchemaFilename)
	})

	t.Run("will find config in parent dirs", func(t *testing.T) {
		t.Chdir(filepath.Join(testDir, "testdata", "cfg", "otherdir"))

		cfg, err = LoadConfigFromDefaultLocations()
		require.NoError(t, err)
		require.Equal(t, StringList{"outer"}, cfg.SchemaFilename)
	})

	t.Run("will return error if config doesn't exist", func(t *testing.T) {
		t.Chdir(testDir)

		cfg, err = LoadConfigFromDefaultLocations()
		require.ErrorIs(t, err, fs.ErrNotExist)
	})
}

func TestLoadDefaultConfig(t *testing.T) {
	testDir, err := os.Getwd()
	require.NoError(t, err)
	var cfg *Config

	t.Run("will find the schema", func(t *testing.T) {
		t.Chdir(filepath.Join(testDir, "testdata", "defaultconfig"))

		cfg, err = LoadDefaultConfig()
		require.NoError(t, err)
		require.NotEmpty(t, cfg.Sources)
	})

	t.Run("will return error if schema doesn't exist", func(t *testing.T) {
		t.Chdir(testDir)

		cfg, err = LoadDefaultConfig()
		require.ErrorIs(t, err, fs.ErrNotExist)
	})
}

func TestReferencedPackages(t *testing.T) {
	t.Run("valid", func(t *testing.T) {
		tm := TypeMap{
			"Foo": {Model: StringList{"github.com/test.Foo"}},
			"Bar": {Model: StringList{"github.com/test.Bar"}},
			"Baz": {Model: StringList{"github.com/otherpkg.Baz"}},
			"Map": {Model: StringList{"map[string]any"}},
			"SkipResolver": {
				Fields: map[string]TypeMapField{
					"field": {Resolver: false},
				},
			},
		}

		pkgs := tm.ReferencedPackages()

		assert.Equal(t, []string{"github.com/test", "github.com/otherpkg"}, pkgs)
	})
}

func TestConfigCheck(t *testing.T) {
	for _, execLayout := range []ExecLayout{ExecLayoutSingleFile, ExecLayoutFollowSchema} {
		t.Run(string(execLayout), func(t *testing.T) {
			t.Run("invalid config format due to conflicting package names", func(t *testing.T) {
				config := Config{
					Exec: ExecConfig{
						Layout:   execLayout,
						Filename: "generated/exec.go",
						DirName:  "generated",
						Package:  "graphql",
					},
					Model: PackageConfig{Filename: "generated/models.go"},
				}

				require.EqualError(
					t,
					config.check(),
					"exec and model define the same import path (github.com/99designs/gqlgen/codegen/config/generated) with different package names (graphql vs generated)",
				)
			})

			t.Run("federation must be in exec package", func(t *testing.T) {
				config := Config{
					Exec: ExecConfig{
						Layout:   execLayout,
						Filename: "generated/exec.go",
						DirName:  "generated",
					},
					Federation: PackageConfig{Filename: "anotherpkg/federation.go"},
				}

				require.EqualError(
					t,
					config.check(),
					"federation and exec must be in the same package",
				)
			})

			t.Run("federation must have same package name as exec", func(t *testing.T) {
				config := Config{
					Exec: ExecConfig{
						Layout:   execLayout,
						Filename: "generated/exec.go",
						DirName:  "generated",
					},
					Federation: PackageConfig{
						Filename: "generated/federation.go",
						Package:  "federation",
					},
				}

				require.EqualError(
					t,
					config.check(),
					"exec and federation define the same import path (github.com/99designs/gqlgen/codegen/config/generated) with different package names (generated vs federation)",
				)
			})

			t.Run("deprecated federated flag raises an error", func(t *testing.T) {
				config := Config{
					Exec: ExecConfig{
						Layout:   execLayout,
						Filename: "generated/exec.go",
						DirName:  "generated",
					},
					Federated: true,
				}

				require.EqualError(
					t,
					config.check(),
					"federated has been removed, instead use\nfederation:\n    filename: path/to/federated.go",
				)
			})
		})
	}
}

func TestAutobinding(t *testing.T) {
	t.Run("valid paths", func(t *testing.T) {
		cfg := Config{
			Models: TypeMap{},
			AutoBind: []string{
				"github.com/99designs/gqlgen/codegen/config/testdata/autobinding/chat",
				"github.com/99designs/gqlgen/codegen/config/testdata/autobinding/scalars/model",
			},
			Packages: code.NewPackages(),
		}

		cfg.Schema = gqlparser.MustLoadSchema(&ast.Source{Name: "TestAutobinding.schema", Input: `
			scalar Banned
			type Message { id: ID }
		`})

		require.NoError(t, cfg.autobind())

		require.Equal(
			t,
			"github.com/99designs/gqlgen/codegen/config/testdata/autobinding/scalars/model.Banned",
			cfg.Models["Banned"].Model[0],
		)
		require.Equal(
			t,
			"github.com/99designs/gqlgen/codegen/config/testdata/autobinding/chat.Message",
			cfg.Models["Message"].Model[0],
		)
	})

	t.Run("normalized type names", func(t *testing.T) {
		cfg := Config{
			Models: TypeMap{},
			AutoBind: []string{
				"github.com/99designs/gqlgen/codegen/config/testdata/autobinding/chat",
				"github.com/99designs/gqlgen/codegen/config/testdata/autobinding/scalars/model",
			},
			Packages: code.NewPackages(),
		}

		cfg.Schema = gqlparser.MustLoadSchema(&ast.Source{Name: "TestAutobinding.schema", Input: `
			scalar Banned
			type Message { id: ID }
			enum ProductSKU { ProductSkuTrial }
			type ChatAPI { id: ID }
		`})

		require.NoError(t, cfg.autobind())

		require.Equal(
			t,
			"github.com/99designs/gqlgen/codegen/config/testdata/autobinding/scalars/model.Banned",
			cfg.Models["Banned"].Model[0],
		)
		require.Equal(
			t,
			"github.com/99designs/gqlgen/codegen/config/testdata/autobinding/chat.Message",
			cfg.Models["Message"].Model[0],
		)
		require.Equal(
			t,
			"github.com/99designs/gqlgen/codegen/config/testdata/autobinding/chat.ProductSku",
			cfg.Models["ProductSKU"].Model[0],
		)
		require.Equal(
			t,
			"github.com/99designs/gqlgen/codegen/config/testdata/autobinding/chat.ChatAPI",
			cfg.Models["ChatAPI"].Model[0],
		)
	})

	t.Run("with file path", func(t *testing.T) {
		cfg := Config{
			Models: TypeMap{},
			AutoBind: []string{
				"../chat",
			},
			Packages: code.NewPackages(),
		}

		cfg.Schema = gqlparser.MustLoadSchema(&ast.Source{Name: "TestAutobinding.schema", Input: `
			scalar Banned
			type Message { id: ID }
		`})

		require.EqualError(
			t,
			cfg.autobind(),
			"unable to load ../chat - make sure you're using an import path to a package that exists",
		)
	})
}

func TestLoadSchema(t *testing.T) {
	t.Parallel()

	getConfig := func(t *testing.T) *Config {
		t.Helper()

		cfgFile, err := os.Open("testdata/cfg/glob.yml")
		require.NoError(t, err)
		t.Cleanup(func() { _ = cfgFile.Close() })
		c, err := ReadConfig(cfgFile)
		require.NoError(t, err)
		return c
	}

	t.Run("valid schema", func(t *testing.T) {
		cfg := getConfig(t)

		cfg.Sources = []*ast.Source{
			{
				Input: `
					type Query {
					  message: Message
					}
					type Message { id: ID }
				`,
			},
		}
		err := cfg.LoadSchema()
		require.NoError(t, err)
		require.NotNil(t, cfg.Schema)
	})

	t.Run("invalid schema", func(t *testing.T) {
		cfg := getConfig(t)

		cfg.Sources = []*ast.Source{
			{
				Input: `
					type Query {
						// should have fields here
					}
					type Message { id: ID }
				`,
			},
		}
		err := cfg.LoadSchema()
		require.Error(t, err)
		require.Nil(t, cfg.Schema)
	})
}

func FuzzReadConfig(f *testing.F) {
	f.Add([]byte(`schema: schema.graphql`))
	f.Add([]byte(`schema:
  - "*.graphql"
model:
  filename: models_gen.go`))
	f.Add([]byte(`schema: schema.graphql
exec:
  filename: generated.go
  package: graphql`))
	f.Add([]byte(`schema: schema.graphql
model:
  filename: models.go
  package: models
exec:
  filename: exec.go`))
	f.Add([]byte(``))
	f.Add([]byte(`asdf`))
	f.Add([]byte(`schema: outer
unknown: foo`))

	f.Fuzz(func(t *testing.T, configData []byte) {
		cfg, err := ReadConfig(strings.NewReader(string(configData)))

		if err == nil && cfg == nil {
			t.Fatal("ReadConfig returned nil config without error")
		}
	})
}
